#!/usr/bin/perl -w

# $Id: sqlbackup.pl 7557b5d4d82b 2012/12/02 09:07:16 pgurumur $
# Copyright (c) 2009 Prabhu Gurumurthy <pgurumur@gmail.com>
# 
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# 
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#       

use strict;
use warnings;
use Digest::MD5;
use Getopt::Long;
use File::Copy;
use Mail::Sendmail 0.75;
use POSIX;
use POE;
use Sys::Syslog;
use Sys::Syslog qw(:DEFAULT setlogsock);
# use lib "/usr/ssn/lib/perl5/";
use ConfigCopy;
use ConfigSSH;
use ConfigSQL;
use ConfigRCS;

Getopt::Long::Configure ('default', 'bundling');

# Similar to basename
push my @prog, split(/\//, $0);
my $program = $prog[$#prog];

# See whether we have any arguments, if not, print usage and exit.
my $arg = scalar(@ARGV);

# where I store my command line options
my %opt = ();

# MySQL handle
my $sql = undef;

# Sleep time before the device backs itself up!
# Default is 20 seconds, tunable with -s or --sleep option
my $sleep = undef;

# Where is my backup directory?
my $backup_dir = "/tftpboot/bckup";

# Where are my rcs, co, ci, rcsdiff binaries located?
my $bindir = "/usr/bin";

# How many number of tasks
my $taskMax = 5;

# My RCS handle 
# bindir, rcs directory (where RCS director will be located), workdir ( where
# files to be rcs'ed will be located)
my $rcs = new ConfigRCS($bindir, $backup_dir, $backup_dir);

# usage function, prints what this can take as command line options and exits
sub usage {
   print "$program options\n";
   print "options: \n";
   print " -d | --database    [ Use database instead of configuration file ]\n";
   print " -C | --conf        [ Database configuration file ]\n";
   print " -s | --sleep       [ Sleep time for system to back itself up ]\n";
   print " -t | --task        [ maximum concurrent task, default: 5 ]\n";
   print " -? | --help        [ prints this help ]\n";
   exit (1);
}

# Open SQL handle based on the self explaing parameters given.
sub sqlOpen {
   my ($hostname, $database, $username, $password) = @_;

   my $retval = 1;
   # I dont want to over ride an existing handle
   if (!defined($sql)) {
      if (defined($hostname) and defined($database) and defined($username) 
            and defined($password)) {
         $sql = new ConfigSQL($database, $hostname, $username, $password); 
         $retval = 0;
      }
   }

   return $retval;
}

# Close SQL handle if it is opened
sub sqlClose {
   
   if (defined($sql)) {
      $sql->close();
   }

}

# Do the sql query and return the array, the caller is responsible for
# getting the values from the array, not this function as I cannot determine
# what type of query is passed.
sub sqlSelect {
   my ($sqlQuery) = @_;

   my @retval = ();
   if (defined($sqlQuery) and defined($sql)) {
      @retval = $sql->execute($sqlQuery);
   }

   return @retval;
}

# Function to use for Update and Insert
sub sqlInsert {
   my ($sqlQuery) = @_;

   if (defined($sqlQuery) and defined($sql)) {
      $sql->insert($sqlQuery);
   }
}


# Wrapper function syslog
sub mesglog {
   my ($facility, $message) = @_;

   if (defined($message)) {
      if (!defined($facility) or ($facility eq 0)) {
         $facility = "info";
      }

      syslog($facility, "%s\n", $message);
   }
}

# fatal, as the function says, prints the message and exists after closing the
# sql handle, if $email is defined and it is 1, then we send email out using
# email function (caveat, email itself uses fatal, but with $email eq 0)
# otherwise it will be a loop
sub fatal {
   my ($email, $message) = @_;

   my @msg = ();
   if (defined($message)) {
      if (defined($sql)) {
         sqlClose();
      }

      mesglog(0, "%s: %s", $program, $message);

      if (defined($email) and ($email eq 1)) {
         push @msg, $message;
         # XXX: POSIX::uname() is use in multiple places, dont know whether we 
         # can use it only once and reference the variable
         my ($sys, $node, $release, $ver, $machine) = POSIX::uname();
         my $contact = 'netreports@silverspringnet.com';
         my $subject = sprintf("%s encountered problem on %s", $program, $node);
         email($contact, $subject, @msg);
      }
      exit(1);
   }
}

# Send mail, (as the function suggests) 
sub email {
   my ($contact, $subject, @body) = @_;

   my %mail = undef;
   my ($sys, $node, $release, $ver, $machine) = POSIX::uname();

   if (defined($contact)) {
      my $from = 'netreports@silverspringnet.com';
      %mail = (
         from => $from,
         to => $contact,
         subject => $subject);

      $mail{body} = @body;
      sendmail(%mail) or fatal(0, "$Mail::Sendmail::error");
   }
}

# Where the backup process is being done, in this way I am merging the perl 
# script and the shell script into one and it easier to debug for any perl
# monks (too bad shell guys, learn some perl)
sub rcs_backup {
   my ($system, $contact, $orig_conf, $new_conf) = @_;

   # Get the time is nice format instead of numbers
   my $now = localtime();

   # We default to root and oper group, used by chown down below
   # SSN specific
   my $uid = 0;
   my $gid = 2002;

   # Check whether the new configuration file is present and is non zero 
   # ( byte wise )
   if (-f $new_conf) {
      if (-z $new_conf) {
         fatal(1, "empty backup configuration for $system");
      }
   }
   else {
      fatal(1, "no new backup configuration file found for $system");
   }

   # Huh? If I dont have an original configuration (empty configuration is
   # fine), how would RCS proceed?
   if (! -f $orig_conf) {
      fatal(1, "cannot find the configuration file for $system");
   }

   if (defined($rcs)) {
      # checkout the original configuration file, I don't care whether it
      # is empty or not empty
      $rcs->checkout($orig_conf);

      # Once checked out, move the new configuration file to original one
      move($new_conf, $orig_conf);

      # Get the RCS diff, since we moved it right
      my @changed = $rcs->diff();

      # message for rcs checkin
      my $message = sprintf("automatic checkin of %s config file on %s",
         $system, $now);

      # Checkin the file
      $rcs->checkin($orig_conf, $message);

      # chown the file, so that only root and oper group people can use it
      chown $uid, $gid, $orig_conf;

      # send email out! Yippeee.. 
      my $subject = sprintf("configuration changed on %s", $system);
      email($contact, $subject, @changed);
   }
}

# How many concurrent tasks
sub concurrent_tasks () { $taskMax; }

# function, that start tasks
sub taskStart {
   my ($kernel, $heap) = @_[KERNEL, HEAP];
   while (keys(%{$heap->{task}}) < concurrent_tasks) {
      my $sql_query = "select active, system, snmp_id, ssh_id, ";
      $sql_query .= "tftp_server, system_contact from config_backup;";
      my @values = sqlQuery($sql_query);
      my $system = shift @values;
      last unless defined $system;

      my $task = POE::Wheel::Run->new( 
         Program => sub {backup(@$system);}, 
         StdoutFilter => POE::Filter::Reference->new(),
      );
      $heap->{task}->{$task->ID} = $task;
      $kernel->sig_child($task->PID, "sig_child");
   }
}

# Once the task is done, delete the child wheel, and try to start a new
# task to take its place
sub taskDone {
   my ($kernel, $heap, $taskId) = @_[KERNEL, HEAP, ARG0];
   delete $heap->{task}->{$taskId};
   $kernel->yield("next_task");
}

# Detect the CHLD signal as each of our children exits.
sub sig_child {
   my ($heap, $signal, $pid, $exitValue) = @_[HEAP, ARG0, ARG1, ARG2];
   my $details = delete $heap->{$pid};
}

# main function, that backs up the config
sub backup { 
   my ($active, $system, $snmpId, $sshId, $tftp, $contact) = @_; 
   my $filter = POE::Filter::Reference->new();

   # If tftp is not defined, exit, we rely on TFTP for backups  
   if (!defined($tftp)) { 
      fatal(1, "cannot proceed without tftp server defined!"); 
   } 

   # check to see whether the systems is active, means  whether we need to back 
   # it up or not!  
   if ($active eq 1) { 

      # All system require SNMP to be active.  This is the index into the 
      # snmp_table, without this the script will be unable to proceed as we 
      # backup all Cisco IOS devices using snmp and differentiate between IOS 
      # and ASA/PIX using sysDescr.0 snmpget 
      if ($snmpId eq 0) { 
         fatal(1, "cannot determine SNMP information for $system"); 
      } 

      # Since we got the snmp_id from the config_backup table,
      # get the snmp values from snmp_info table 
      my $sql_query = sprintf("select * from snmp_info where id = %d;", $snmpId); 
      my @snmp = sqlQuery($sql_query); 
      my ($id, $ver, $ro, $rw, $user, $engine, $auth, $pass, $time) = $snmp[0]; 

      # Right now this script only cares about version 2/1 even though the 
      # underlying modules support it, this does not yet!  
      # Create the ConfigCopy object, this is used to backup the config in case 
      # of IOS, and get the system description if it is an ASA/PIX 
      my $ccopy = new ConfigCopy; 
      my $retval = $ccopy->set_params($system, $rw, undef, $tftp, $sleep); 
      if ($retval ne 0) { 
         fatal(1, $ccopy->get_error()); 
      }

      # If we do need to back it up, see whether it is using ssh or not, usually 
      # SSH means we are backing up an ASA or PIX 
      if ($sshId ne 0) { 

         # Same as SNMP, since we get the ssh_id, which is an index to the 
         # ssh_info table, get all the values from ssh_info for this matching id;
         $sql_query = sprintf("select * from ssh_info where id = %d;", $sshId); 
         my @ssh = sqlQuery($sql_query); 
         my ($id, $user, $passwd, $enable, $time) = $ssh[0]; 

         # This is where we check to see whether username and password are defined
         if (!defined($user)) { 
            $ccopy->close(); 
            fatal(1, "no user info for $system to connect over ssh"); 
         }

         if (!defined($passwd)) { 
            $ccopy->close(); 
            fatal(1, "no passwd info for $system to connect over ssh");
         }

         # Yeah, get the snmp description
         my $descr = $ccopy->get_device();
         
         # Proceed only if it is an ASA/PIX
         if (($descr == "asa") or ($descr == "pix")) { 
            # connect to it (the second parameter is port, which is intentionally 
            # left undef, so that the underlying module will connect to default 
            # port of 22, change it if the need arises later on 
            if($ccopy->ssh_connect($system, undef, $user, $passwd, undef) ne 0) {
               $ccopy->close(); 
               fatal(1, $ccopy->get_error()); 
            }
         }
      }

      if ($ccopy->get_config() eq 1) { 
         $ccopy->close(); 
         fatal(1, $ccopy->get_error());
      }

      my $config_file = $ccopy->get_config_file();

      # If config file is defined,
      if (defined($config_file)) { 

         # my target configuration will be a .new file 
         my $target_file = sprintf("%s/%s.cfg.new", $backup_dir, $system); 

         # Do RCS based backup, using Rcs module 
         rcs_backup($system, $contact, $config_file, $target_file);

         my $ctx = Digest::MD5->new;
         $ctx->addfile($config_file);
         my $digest = $ctx->hexdigest;

         # Update the timestamp and MD5 sum of when we did the backup on the 
         # SQL table
         $sql_query = "update config_backup set timestamp = NULL,";
         $sql_query .= sprintf(
            "digest = \"%s\" where system = \"%s\";", $digest, $system);

         # Update the SQL db
         sqlInsert($sql_query);
      }

      # close the config copy module
      $ccopy->close();
   }
}

# Check to see whether we have arguments
if ($arg) {
   # Use UNIX socket, bye bye windows
   setlogsock("unix");
   openlog("$program $$", "cons,pid", "user");
   # parse 'em all
   GetOptions(\%opt, "database|d", "conf|C=s", "sleep|s=i", "task|t=i", 
      "help|?") or usage();
   usage() if $opt{help};

   if ($opt{sleep}) {
      $sleep = $opt{sleep};
   }
   else {
      $sleep = 20;
   }

   if ($opt{task}) {
      $taskMax = $opt{task};
   }

   # array where I store my pid from fork
   my @childs = ();
   
   # If database option is defined!
   if ($opt{database}) {
      if ($opt{conf}) {
         my $database = undef;
         my $hostname = undef;
         my $username = undef;
         my $password = undef;

         my $count = 0;
         # Open file, otherwise fatal it,
         open (FH, $opt{conf}) or fatal(1, "cannot open $opt{conf}: $!");
         # simple parser with the following syntax
         # db ssndb
         # host 10.200.1.5
         # user nflanders
         # pass 23Silver$
         #
         while (<FH>) {
            $count++;
            # I dont care if we have white spaced empty line or
            # any line that starts with # sign
            next if /^\s*#/;
            next if /^\s*$/;
            chomp(my $line = $_);

            if ($line =~ /^\s*(db|host|user|pass)\s+(.*)\s*$/) {
               if ($1 eq "db") {
                  chomp($database = $2);
               }
               elsif ($1 eq "host") {
                  chomp($hostname = $2);
               }
               elsif ($1 eq "user") {
                  chomp($username = $2);
               }
               elsif ($1 eq "pass") {
                  chomp($password = $2);
               }
               else {
                  close(FH);
                  fatal(0, "unknown token $1 on line $count");
               }
            }
         }

         # close the handle, yay yay!
         close(FH);

         if (sqlOpen($database, $hostname, $username, $password) != 0) {
            fatal(0, "problem opening sql handle!");
         }

         POE::Session->create(
            inline_states => 
            {
               _start => \&taskStart,
               next_task => \&taskStart,
               task_done => \&taskDone,
               sig_child => \&sig_child,
            }
         );

         # Run until there are no more tasks;
         $poe_kernel->run();

         # Close the SQL handle
         sqlClose();
      }
   }
}
else {
   usage();
}
# End
